feat(sdk,slack): webhook sources, agent channels, and human-in-the-loop - #4537
feat(sdk,slack): webhook sources, agent channels, and human-in-the-loop#4537ericallam wants to merge 29 commits into
Conversation
🦋 Changeset detectedLatest commit: b9797ec The changes in this PR will be included in the next version bump. This PR includes changesets to release 30 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: Your plan includes up to 10 reviews per rolling hour; 8 remain after this review. 📜 Recent review details⏰ Context from checks skipped due to timeout. (37)
WalkthroughAdded typed webhook sources, provider verification, webhook tasks, and durable 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
b4b9f89 to
274c3ab
Compare
@trigger.dev/build
trigger.dev
@trigger.dev/core
@trigger.dev/python
@trigger.dev/react-hooks
@trigger.dev/redis-worker
@trigger.dev/rsc
@trigger.dev/schema-to-json
@trigger.dev/slack
@trigger.dev/sdk
commit: |
8f0664d to
0a23edf
Compare
38468bb to
2718f32
Compare
39c0585 to
0debb64
Compare
#4344) ## Summary The server half of hosted webhooks: the public ingress endpoint, signature verification, the delivery pipeline (Postgres partitioned storage + ClickHouse for ordering), the in-app partition manager, the HTTP API, and the dashboard (Deliveries, Endpoints, and the in-app test console). The public SDK and docs half is #4537. That PR carries the user-facing API (`webhook()`, `chat.event` / `chat.channels`, the `@trigger.dev/slack` connector) and builds on the shared `@trigger.dev/core` schemas that ship here. ## Shipping behind a flag A `WEBHOOK_ENABLED` env var (default off) gates the public ingress route and the engine worker plus partition cron, so merging and deploying this changes nothing in production until it is flipped on per environment. The dashboard is separately gated per org by the `hasWebhooksAccess` feature flag. ## Note on packages This PR includes the `@trigger.dev/core` schema additions the server compiles against, but carries no changeset. Core is not consumed independently of the SDK, so it is released together with the SDK via #4537. Keeping its changeset off `main` means no release cut from `main` publishes it early.
1399cdb to
b3bb085
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
…fter one decision defaultSlackFinalizeInteraction re-rendered the message by dropping every actions block, so once one tool call was approved or denied the other pending tool calls in the same message lost their buttons and their turn could not resume. Drop only the resolved tool call's actions block (matched by the toolCallId:: button value prefix) and keep the rest.
…rl-secret warning The typed-event example now passes webhooks.stripe<Stripe.Event>() to webhook() so it actually demonstrates typed access to event. The verification section no longer claims every idempotency key comes from a provider event id (it documents the raw-body/timestamp/signature fallback), and adds a warning that url-secret exposes the secret in the URL.
…st harness sendChannelEvent/deliverChannelEvent default connectorId to DEFAULT_TEST_CONNECTOR_ID, not to a sole configured connector's id as the previous doc implied. Describe the actual default (which lines up with recordingChannelConnector's own default id).
…channel interaction A duplicate or late channel interaction (e.g. a repeated Slack approval click) whose toolCallId matches no pending tool call was dropped without detaching the per-turn session.in listener. That listener marks inbound records CONSUMED, so after the drop the next user message was swallowed into pendingWireMessages (already drained before the wait) and the turn idled until the timeout, ending the run and losing the message. Mirror the action path: detach msgSub and decrement turn (a dropped interaction is not a turn) before falling through to the wait.
…l error callbacks Two channel-connector fixes in the chat.agent turn loop: - Under a pendingMessages-enabled agent, the mid-turn session.in handler returned early for any record without a steerable `.message`. Channel deliveries carry `channelEvent` and no `.message`, so an inbound Slack message or block_actions approval click arriving mid-turn was neither steered nor buffered, and never became a turn. Channel records now fall through to pendingWireMessages so they dispatch as the next turn. - In the turn error handler, resolveReactionChoice (which invokes the user's reactions.error callback) and channelConn.outbound ran unguarded. A throw escaped the catch, skipping the error chunk, turn-complete, and wait-for-next-message, killing the whole conversation. Both are now wrapped so a bad user callback degrades to a warn.
…eMessages agents A channel interaction (e.g. a Slack approval click) is resolved at the top of the turn by matching its toolCallId against the in-memory accumulator. For agents that register hydrateMessages, the accumulator is empty on a fresh continuation / OOM-retry boot (those agents own persistence, so the boot seeding block is skipped and the hydrate hook only runs later in the turn). The click therefore matched nothing, was classified as a stale interaction, and was dropped, losing the human's decision and leaving the paused turn unresolved. When an interaction arrives and hydrateMessages is registered but the accumulator is empty, load the persisted chain via the hydrate hook first so the interaction can find its pending tool call. The per-turn hydrate later in the turn still runs to persist the synthesized resolution message. Guarded on an empty accumulator so warm turns don't re-hydrate.
…l error egress - buildInteractionResolutionMessage matched a tool part by toolCallId without checking it was still input-available, so a duplicate or retried approval/deny click on an already-answered tool call built a fresh resolution and ran the decision a second time. Gate on state === 'input-available' so a resolved call resolves to a dropped stale interaction, matching the function's documented contract. - The turn-error channel egress only edited the start-of-turn ack placeholder, so a connector configured with ack: null posted nothing on a failed turn (the user saw silence). Send a fresh final message when there is no placeholder ref. The error text is now run through the agent's onError option (same sanitizer the browser stream uses) instead of forwarding turnError.message verbatim to the channel surface.
…'t hit a TDZ The channel-interaction pre-hydrate added for hydrateMessages agents referenced clientData, but clientData was declared with const later in the same turn block. The early reference hit the temporal dead zone and threw ReferenceError, which the local try/catch swallowed, so hydration silently never ran and the button click was still dropped as a stale interaction. Parse clientData once above the channel-event block so both the interaction hydrate and the rest of the turn share the same value.
…acing, don't clobber posted answers
- channelReplyText selected parts with 'text' in p, which also matches AI
SDK reasoning parts ({ type: 'reasoning', text }). With sendReasoning
defaulting to true, a reasoning-capable model's private chain-of-thought
was concatenated into the text posted to Slack. Narrowed the selection to
parts whose type is exactly 'text'.
- normalizeKeyString namespaced only the whole placeholder, so a fallback
key like {a || b} (the Slack connector's DEFAULT_KEY form) left every
alternative after the first bare, diverging from ValidatedWebhookKey which
validates each || side and defaults bare sides to the body. It now splits
on ||, trims, and namespaces each alternative. Added test cases.
- On the turn-error path the error text was sent with previousRef pointing
at the ack placeholder. If a hook after the final answer was posted threw
(e.g. onTurnComplete), the delivered answer was edited into the error
string. Track whether the final answer was posted and, if so, send the
error as a fresh follow-up instead of editing over the answer.
…ll is absent; unspace Slack guard in-list - The channel-interaction pre-hydrate was gated on an empty accumulator, so a hydrate-backed agent whose in-memory accumulator was non-empty but no longer held the pending tool part (after a chat.history mutation or a compaction trim) would skip the hydrate and drop the click as stale. Gate instead on the pending toolCallId being absent from the accumulator, so the persisted chain is reloaded whenever the call can't be found (a duplicate click on an already-resolved call still short-circuits without a needless hydrate, since the call is present). - Unspace the Slack SELF_MESSAGE_GUARD in-list to match the documented filter style. The runtime parser already skips whitespace so this is cosmetic, but it keeps the guard consistent with the docs example.
…, skip unroutable channel turns
- webhooks.gitlab used the Svix preset (svix-id/svix-timestamp/svix-signature,
HMAC over {id}.{timestamp}.{body}), but GitLab doesn't sign bodies: it echoes
the configured token in X-Gitlab-Token. Every GitLab delivery failed
verification. Switched it to the shared-secret scheme (header, x-gitlab-token).
- The Slack connector declared secretProvisioning 'integrator', so the Connect
panel offered a Generate-secret button, but Slack mints its own Signing Secret
that must be pasted (core already classifies slack as 'provider'). Changed to
'provider'.
- A verified channel delivery naming a connector the running agent doesn't have
fell through to a normal turn with an empty incoming message, so the agent
answered nothing and burned tokens. Handle the unresolved-connector case like a
stale interaction: warn with the connectorId/deliveryId and skip the turn
without consuming a turn.
…ocks A text-only reply issued chat.update with text alone, so Slack kept whatever blocks were already on the message (e.g. approval controls previously rendered into the same ts). Send blocks: [] on edits when the outgoing message has no blocks, so an edit is authoritative. postMessage still omits blocks when empty.
…ai.ts formatting The SELF_MESSAGE_GUARD in-list was unspaced earlier to match the documented filter style, but its unit test still asserted the spaced substring. Update the assertion. Also apply oxfmt to a wrapped line in the channel error-reaction block that exceeded the width limit.
…n't fail a delivered turn The turn-start 'working' resolver and the success-path 'done' resolver both awaited user-supplied reactions.working / reactions.done callbacks unguarded. A rejection propagated into the turn catch, which then posted an error to the channel and skipped onTurnComplete / the snapshot write, turning an already-delivered answer into a reported failure. Wrapped both in try/catch that degrade to a warn, matching the error-path reaction handling.
| /** The verified event body, typed by the source (a preset type, or the `<T>` you supply). */ | ||
| event: TEvent; | ||
| /** The inbound request headers (case-insensitive, Web `Headers`). e.g. headers.get("x-github-event"). */ | ||
| headers: Headers; | ||
| ctx: TaskRunContext; | ||
| }; | ||
|
|
||
| export type WebhookOptions<TIdentifier extends string, TSource extends AnyWebhookSource> = { | ||
| id: TIdentifier; | ||
| source: TSource; |
There was a problem hiding this comment.
🟡 Generic Slack webhook source can never be connected because it does not answer Slack's URL-verification handshake
The auto-generated Slack source (providerProducers at packages/trigger-sdk/src/v3/webhooks.ts:204-213) is built without the one-time URL-verification handshake or form-payload handling that Slack requires, so a Slack app can never save the endpoint URL.
Impact: Anyone who wires up a plain Slack webhook with the SDK is blocked at setup — Slack rejects the endpoint and no events ever arrive.
Missing handshake/formPayload compared with the Slack connector package
providerProducers is generated from webhookProviderConfigs in packages/core/src/v3/webhooks/index.ts:154, which includes a slack entry. The producer emits verifier: { kind: "config", config: entry.config() } only.
By contrast the dedicated connector in packages/slack/src/index.ts:41-61 builds the same HMAC config but additionally supplies:
handshake: SLACK_HANDSHAKE— echoes theurl_verificationchallengeSlack sends when you first save an Event Subscriptions request URL.formPayload: { field: "payload" }on the verifier config — Slack posts interactivity callbacks asapplication/x-www-form-urlencodedwith the JSON in apayloadfield.
Without the handshake, Slack's "Verify" step fails and the URL cannot be saved. Without formPayload, any interactivity callback body cannot be parsed.
Either exclude slack from the auto-generated producers (pointing users at @trigger.dev/slack), or give the generated producer the same handshake/form-payload configuration.
Prompt for agents
packages/trigger-sdk/src/v3/webhooks.ts generates one producer per entry of webhookProviderConfigs (packages/core/src/v3/webhooks/index.ts). That table contains a `slack` entry, so `webhooks.slack()` is now a public SDK source that only carries the HMAC verifier config. Slack additionally requires (a) answering the one-time `url_verification` handshake before an Event Subscriptions request URL can be saved, and (b) parsing form-encoded interactivity callbacks out of the `payload` field. The dedicated connector in packages/slack/src/index.ts supplies both (SLACK_HANDSHAKE and formPayload on the verifier config), but the generated producer does not, so an endpoint built from `webhooks.slack()` can never be connected. Decide whether to exclude `slack` from the generated producers (and document `@trigger.dev/slack` as the way to receive Slack events) or to attach the handshake + formPayload configuration to the generated Slack producer so it matches the connector.
Was this helpful? React with 👍 or 👎 to provide feedback.
| /** | ||
| * Wait for a channel turn's post-completion egress to land. The run loop | ||
| * writes the `trigger:turn-complete` chunk (which unblocks the harness's | ||
| * turn-complete latch) BEFORE it awaits the connector's final `send()` and | ||
| * its done/error reactions. Draining a handful of macrotasks lets those | ||
| * awaited-but-immediate calls settle so `sendChannelEvent` callers can assert | ||
| * against the connector's recorded sends/reactions deterministically. | ||
| */ | ||
| async function settlePostTurnChannelEgress(): Promise<void> { | ||
| for (let i = 0; i < 5; i++) { | ||
| await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| } | ||
| } |
There was a problem hiding this comment.
🔍 Channel egress runs after the turn-complete chunk is written, which the test harness has to paper over
writeTurnCompleteChunk is awaited at the end of the turn, and only then is the connector's final send() and the done/error reactions awaited. That ordering means the browser-facing stream closes before the channel reply lands, and any observer keyed on turn-complete (including the test harness) sees the turn as finished with no reply posted yet. mock-chat-agent.ts compensates with settlePostTurnChannelEgress(), a fixed drain of 5 macrotasks, which is inherently timing-based: a connector whose send() does real async work (a fetch, as @trigger.dev/slack does) would not settle within that window, so channel assertions in future tests could be flaky. Consider having the harness await an explicit egress-completed signal instead of draining the microtask/macrotask queue.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
The public SDK and docs half of hosted webhooks:
webhook()with typed provider sources (webhooks.stripe(),webhooks.github(),webhooks.svix(), and more, pluswebhooks.custom<T>()),chat.eventandchat.channelsfor agent channels, human-in-the-loop tool approvals, the new@trigger.dev/slackconnector, and the webhooks docs section.Stacked on the server PR
This is the top of a stack. Its base is #4344 (the server half: ingress, delivery pipeline, dashboard, and the shared
@trigger.dev/coreschemas this SDK builds on), so the diff here is API-only and it builds against a base that already has core.The single changeset in this PR bumps
@trigger.dev/core,@trigger.dev/sdk,@trigger.dev/slack, andtrigger.devtogether, so core (whose code lands via #4344) is published alongside the SDK.Merge order
Merges after #4344. The plan: land and deploy the server behind its flag, cut prerelease (rc) packages for early users to test against the live environment, then merge this and cut the real release once the feature is live. When #4344 merges, GitHub retargets this PR's base to
mainautomatically.